<!DOCTYPE html>
<html>

<head>
  <meta charset="utf8">
  <title>Graph debugger</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      font: 14px/20px sans-serif;
      background: #fff;
      color: #000;
    }

    @media (prefers-color-scheme: dark) {
      body {
        background: #222;
        color: #ddd;
      }
    }

    canvas {
      position: fixed;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
    }

    #instructions {
      padding: 20px;
    }

  </style>
</head>

<body>
  <div id="instructions">
    This is a debugger for some of esbuild's internals. To use:
    <ol>
      <li>Set "debugVerboseMetafile = true"</li>
      <li>Serve the esbuild repo over localhost</li>
      <li>Visit this page with "#metafile=path/to/metafile.json"</li>
    </ol>
  </div>
  <canvas></canvas>
  <script type="module">

    const lightColors = {
      bg: '#fff',
      fg: '#000',
      accent: '#7BF',
    }

    const darkColors = {
      bg: '#222',
      fg: '#ddd',
      accent: '#FB0',
    }

    const prefersColorSchemeDark = matchMedia("(prefers-color-scheme: dark)")

    function lightOrDarkColors() {
      return prefersColorSchemeDark.matches ? darkColors : lightColors
    }

    const paddingX = 10
    const paddingY = 10

    class InputFile {
      constructor(source, data) {
        this.source = source
        this.data = data
        this.x = 0
        this.y = 0
        this.w = 0
        this.h = 0
        this.measure()
      }

      measure() {
        this.w = 0
        this.h = 0

        // Title
        c.font = titleFont
        this.w = Math.max(this.w, c.measureText(this.source).width)
        this.h += titleLineHeight

        // Parts
        let prevIndent = 0
        c.font = codeFont
        for (const part of this.data.parts) {
          let code = part.code.replace(/\t/g, '  ')
          const lastNewline = code.lastIndexOf('\n')
          let nextIndent = 0
          if (lastNewline >= 0) {
            const lastLine = code.slice(lastNewline + 1)
            nextIndent = lastLine.length
            if (!/\S/.test(lastLine)) code = code.slice(0, lastNewline)
          }
          code = ' '.repeat(prevIndent) + code
          prevIndent = nextIndent
          part.lines = code.split('\n')
          part.y = this.h
          for (const line of part.lines) {
            this.w = Math.max(this.w, c.measureText(line).width)
            this.h += codeLineHeight
          }
          if (part.nsExportPartIndex) {
            this.w = Math.max(this.w, c.measureText('/* <nsExportPartIndex> */').width)
          }
          if (part.wrapperPartIndex) {
            this.w = Math.max(this.w, c.measureText('/* <wrapperPartIndex> */').width)
          }
          part.h = this.h - part.y
        }

        this.w += paddingX * 2
        this.h += paddingY * 2
      }

      render() {
        const colors = lightOrDarkColors()

        // Background
        c.clearRect(this.x, this.y, this.w, this.h)

        // Title
        c.font = titleFont
        c.textBaseline = 'middle'
        c.fillStyle = colors.fg
        c.fillText(this.source, this.x + paddingX, this.y + paddingY + titleLineHeight / 2)

        // Lines
        c.font = codeFont
        c.textBaseline = 'middle'
        c.fillStyle = colors.fg
        for (const part of this.data.parts) {
          c.globalAlpha = part.isLive ? 1 : 0.2
          for (let i = 0; i < part.lines.length; i++) {
            c.fillText(part.lines[i], this.x + paddingX, this.y + paddingY + part.y + i * codeLineHeight + codeLineHeight / 2)
          }
          if (part.nsExportPartIndex) {
            c.fillText('/* <nsExportPartIndex> */', this.x + paddingX, this.y + paddingY + part.y + codeLineHeight / 2)
          }
          if (part.wrapperPartIndex) {
            c.fillText('/* <wrapperPartIndex> */', this.x + paddingX, this.y + paddingY + part.y + codeLineHeight / 2)
          }
        }

        // Border
        c.globalAlpha = 0.2
        c.strokeStyle = colors.fg
        c.strokeRect(this.x, this.y, this.w, this.h)
        c.globalAlpha = 1
      }

      renderHover() {
        const colors = lightOrDarkColors()

        if (this.hoveredPart === -1) {
          c.fillStyle = colors.accent
          c.globalAlpha = 0.2
          c.fillRect(this.x, this.y + paddingY, this.w, titleLineHeight)
          c.globalAlpha = 1
          c.fillRect(this.x, this.y + paddingY, 4, titleLineHeight)

          c.strokeStyle = colors.fg
          c.fillStyle = colors.fg
          for (const part of this.data.parts) {
            if (part.canBeRemovedIfUnused) continue
            drawArrow(
              this.x, this.y + paddingY + titleLineHeight / 2, -1,
              this.x, this.y + paddingY + part.y + codeLineHeight / 2, -1,
            )
          }
        } else if (this.hoveredPart !== null) {
          const part = this.data.parts[this.hoveredPart]
          c.fillStyle = colors.accent
          c.globalAlpha = 0.2
          c.fillRect(this.x, this.y + paddingY + part.y, this.w, part.h)
          c.globalAlpha = 1
          c.fillRect(this.x, this.y + paddingY + part.y, 4, part.h)

          c.strokeStyle = colors.fg
          c.fillStyle = colors.fg
          drawArrow(
            this.x, this.y + paddingY + part.y + codeLineHeight / 2, -1,
            this.x, this.y + paddingY + titleLineHeight / 2, -1,
          )

          for (const dep of part.dependencies) {
            if (dep.source === this.source) {
              const otherPart = this.data.parts[dep.partIndex]
              drawArrow(
                this.x, this.y + paddingY + part.y + codeLineHeight / 2, -1,
                this.x, this.y + paddingY + otherPart.y + codeLineHeight / 2, -1,
              )
              continue
            }

            const otherFile = inputFiles.find(file => file.source === dep.source)
            if (!otherFile) continue
            const otherPart = otherFile.data.parts[dep.partIndex]
            drawArrow(
              this.x + this.w, this.y + paddingY + part.y + codeLineHeight / 2, 1,
              otherFile.x, otherFile.y + paddingY + otherPart.y + codeLineHeight / 2, -1,
            )
          }

          for (const record of part.importRecords) {
            const otherFile = inputFiles.find(file => file.source === record.source)
            if (!otherFile) continue
            drawArrow(
              this.x + this.w, this.y + paddingY + part.y + codeLineHeight / 2, 1,
              otherFile.x, otherFile.y + paddingY + titleLineHeight / 2, -1,
            )
          }

          let lines = []
          lines.push(`isLive: ${part.isLive}`)
          if (part.declaredSymbols.length > 0) {
            lines.push(`declaredSymbols:`)
            for (const declSym of part.declaredSymbols) {
              lines.push(`    ${declSym.name}`)
            }
          }
          if (part.symbolUses.length > 0) {
            lines.push(`symbolUses:`)
            for (const use of part.symbolUses) {
              lines.push(`    ${use.name} ${use.countEstimate}x`)
            }
          }
          if (part.importRecords.length > 0) {
            lines.push(`importRecords:`)
            for (const record of part.importRecords) {
              lines.push(`    ${record.source}`)
            }
          }

          c.font = normalFont
          c.textBaseline = 'middle'
          c.fillStyle = colors.fg
          for (let i = 0; i < lines.length; i++) {
            c.fillText(lines[i], this.x + 10, this.y + this.h + 10 + i * normalLineHeight + normalLineHeight / 2)
          }
        }
      }

      hoveredPart = null

      onHover(mouseX, mouseY) {
        this.hoveredPart = null

        if (mouseX !== null && mouseY !== null &&
          mouseX >= this.x && mouseX < this.x + this.w &&
          mouseY >= this.y && mouseY < this.y + this.h) {
          let y = mouseY - this.y - paddingY

          if (y >= 0 && y < titleLineHeight) {
            this.hoveredPart = -1
            return true
          }

          for (let i = 0; i < this.data.parts.length; i++) {
            const part = this.data.parts[i]
            if (y >= part.y && y < part.y + part.h) {
              this.hoveredPart = i
              return true
            }
          }
        }
      }

      onMouseMove(mouseX, mouseY) {
        if (mouseX >= this.x && mouseX < this.x + this.w &&
          mouseY >= this.y && mouseY < this.y + this.h) {
          document.body.style.cursor = 'move'
          return true
        }
      }

      oldX = 0
      oldY = 0

      onMouseDown(mouseX, mouseY) {
        if (mouseX >= this.x && mouseX < this.x + this.w &&
          mouseY >= this.y && mouseY < this.y + this.h) {
          this.oldX = mouseX
          this.oldY = mouseY
          document.body.style.cursor = 'move'
          return true
        }
      }

      onMouseDrag(mouseX, mouseY) {
        this.x += mouseX - this.oldX
        this.y += mouseY - this.oldY
        this.oldX = mouseX
        this.oldY = mouseY
        document.body.style.cursor = 'move'
      }

      onMouseUp(e) {
      }
    }

    function drawArrow(ax, ay, adx, bx, by, bdx) {
      let dx = bx - ax
      let dy = by - ay
      let d = Math.sqrt(dx * dx + dy * dy)
      let scale = d / 2
      c.beginPath()
      c.moveTo(ax, ay)
      c.bezierCurveTo(
        ax + adx * (10 + scale), ay,
        bx + bdx * 10 + bdx * scale, by,
        bx + bdx * 10, by,
      )
      c.stroke()
      c.beginPath()
      c.moveTo(bx, by)
      c.lineTo(bx + bdx * 10, by - 5)
      c.lineTo(bx + bdx * 10, by + 5)
      c.fill()
    }

    const canvas = document.querySelector('canvas')
    const c = canvas.getContext('2d')
    const titleFont = '20px sans-serif'
    const titleLineHeight = 30
    const codeFont = '12px monospace'
    const codeLineHeight = 18
    const normalFont = '14px sans-serif'
    const normalLineHeight = 18
    let width = 0, height = 0
    let scrollX = 0, scrollY = 0

    let metafile
    const instructions = document.getElementById('instructions')
    try {
      if (!location.hash.startsWith('#metafile=')) throw new Error('Expected "#metafile=" in URL')
      metafile = await fetch(location.hash.slice('#metafile='.length)).then(r => r.json())
    } catch (err) {
      const error = document.createElement('div')
      error.style.color = 'red'
      error.textContent = err
      instructions.append(error)
      throw err
    }
    instructions.remove()

    const outputSource = Object.keys(metafile.outputs)[0]
    const output = metafile.outputs[outputSource]
    const inputFiles = Object.entries(output.inputs).map(([source, data]) => new InputFile(source, data)).reverse()

    for (let i = 0; i < inputFiles.length; i++) {
      const file = inputFiles[i]
      file.y = 100
      if (i === 0) {
        file.x = 100
      } else {
        const prevFile = inputFiles[i - 1]
        file.x = prevFile.x + prevFile.w + 100
      }
    }

    function render() {
      const colors = lightOrDarkColors()

      width = innerWidth
      height = innerHeight
      const ratio = devicePixelRatio
      canvas.width = Math.round(width * ratio)
      canvas.height = Math.round(height * ratio)
      canvas.style.background = colors.bg
      c.scale(ratio, ratio)

      // Title
      c.font = titleFont
      c.textBaseline = 'top'
      c.fillStyle = colors.fg
      c.fillText(outputSource, 10, 10)

      // Content
      c.translate(-scrollX, -scrollY)

      // Inputs
      for (let i = inputFiles.length - 1; i >= 0; i--) inputFiles[i].render()
      for (let i = inputFiles.length - 1; i >= 0; i--) inputFiles[i].renderHover()
    }

    addEventListener('wheel', e => {
      e.preventDefault()
      if (e.ctrlKey) return
      scrollX += e.deltaX
      scrollY += e.deltaY
    }, { passive: false })

    let draggingFile = null
    let isDragging = false

    onmousemove = e => {
      let mouseX = e.pageX + scrollX
      let mouseY = e.pageY + scrollY
      document.body.style.cursor = 'default'
      if (isDragging) {
        if (draggingFile) {
          draggingFile.onMouseDrag(mouseX, mouseY)
        }
      } else {
        for (const file of inputFiles) {
          if (file.onMouseMove(mouseX, mouseY)) {
            break
          }
        }
        onhover(mouseX, mouseY)
      }
    }

    onmousedown = e => {
      let mouseX = e.pageX + scrollX
      let mouseY = e.pageY + scrollY
      if (!isDragging) {
        isDragging = true
        for (const file of inputFiles) {
          if (file.onMouseDown(mouseX, mouseY)) {
            draggingFile = file
            break
          }
        }
      }
      onhover(mouseX, mouseY)
    }

    onmouseup = e => {
      let mouseX = e.pageX + scrollX
      let mouseY = e.pageY + scrollY
      if (isDragging) {
        if (draggingFile) {
          draggingFile.onMouseUp(mouseX, mouseY)
          draggingFile = null
        }
        isDragging = false
      }
      onhover(mouseX, mouseY)
    }

    function onhover(mouseX, mouseY) {
      for (const file of inputFiles) {
        if (file.onHover(mouseX, mouseY)) {
          mouseX = null
          mouseY = null
        }
      }
    }

    onblur = () => {
      draggingFile = null
      isDragging = false
    }

    function tick() {
      requestAnimationFrame(tick)
      render()
    }

    tick()

  </script>
</body>

</html>
